iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 2
0
自我挑戰組

從範例學 D3.js系列 第 2

[02] 從範例學 D3.js - Scaling

  • 分享至 

  • xImage
  •  

這次要學甚麼?
這次透過一個範例,使用 scaleLinear() 做出可以縮放的分布圖

首先,
本篇是基於這個範例時做出來的 Efrat Vilenski - "Scatter plot with zoom"
另外建立於 Observable 可以直接編輯預覽,請搭配使用

目標

我們要將一組具有可轉化為二維座標的資訊,以及表現資料群集大小的屬性
顯示在一個帶有 x-y 軸的分布圖表中
並可放大區塊檢視

Data

首先,我們透過以下方法
隨機產生一組資料
為了呈現差異,我們讓 x, y 座標分布在 0~1000 之間
而每一個圓半徑大小介於 1~200

unction createData(size) {
  let data = [];
  for (let i = 0; i < size; ++i) {
    data.push({
      x: Math.random() * 1000,
      y: Math.random() * 1000,
      size: Math.floor(Math.random() * 200 + 1)
    });
  }
  return data;
}

接著,我們可以透過

  • d3.min(iterable[, accessor])
  • d3.max(iterable[, accessor])
  • d3.median(iterable[, accessor])
    分別取得資料陣列的 最大值最小值中間值

因為我們的每一筆資料是物件 { x, y, size}
因此,在使用時,需要定義 accessor 傳入處理
就如同以下方式:

maxData = {
    x: d3.max(data, d => d.x),
    y: d3.max(data, d => d.y),
    size: d3.max(data, d => d.size)
};
minData = {
    x: d3.min(data, d => d.x),
    y: d3.min(data, d => d.y),
    size: d3.min(data, d => d.size)
};
medianData = {
    x: d3.median(data, d => d.x),
    y: d3.median(data, d => d.y),
    size: d3.median(data, d => d.size)
};

另外,還有一個經常用的方法,目前我常看到的是結合用在 selection.domain()
這個方法就是提供一組資料,最小值與最大值,以 [min, max] 作為回傳的方法:
d3.extent(iterable[, accessor])

因為 scaling 的時候,會需要知道是從哪一組資料區間 (domain),對應轉換成另一組資料區間 (range)
像是長度的單位轉換
這裡,則運用在坐標軸單位轉換,與資料在座標上位置的轉換

Size

為了提供駔標軸顯示的範圍,我們在畫圖的 svg 上,會預留一些空間 (margin)
因此,長寬會再分成整體與繪製分布圖區塊兩者的長寬

Scaling

如前面所提,我們要進行坐標軸單位轉換,與資料在座標上位置的轉換
兩者的轉換方式都是連續而且是線性的 (y = ax + b)
因此,我們使用到 d3.scaleLinear() 方法

如果 d3.scaleLinear() 沒有再指定 domain 與 range 的話,預設皆為 [0, 1]

  • x(): 產生一個方法,做線性轉換;取得所有資料中 x 座標的最小值與最大值,對應至 x 軸顯示的起點與終點
x = d3.scaleLinear()
  .domain(extentX) // extentX = d3.extent(data, d => d.x)
  .range([0, gWidth]);
  • y():產生一個方法,做線性轉換;取得所有資料中 y 座標的最小值與最大值,對應至 y 軸顯示的起點與終點
y = d3.scaleLinear()
  .domain(extentY) // extentY = d3.extent(data, d => d.y)
  .range([0, gHeight])
  • cx():產生一個方法,做線性轉換;取得所有資料中 x 座標的最小值與最大值,對應至資料 x 座標於 x 軸上顯示的位置
cx = d3.scaleLinear()
   .domain(extentX) // extentX = d3.extent(data, d => d.x)
   .range([margin.left, gWidth + margin.left])
  • cy():產生一個方法,做線性轉換;取得所有資料中 y 座標的最小值與最大值,對應至資料 y 座標於 y 軸上顯示的位置
cy = d3.scaleLinear()
   .domain(extentY) // extentY = d3.extent(data, d => d.y)
   .range([margin.top, gHeight + margin.top])
  • size():產生一個方法,做線性轉換;取得所有資料的 size,對應至資料顯示的範圍大小 (此處使用最大至圖表寬的 1/20)
size = d3.scaleLinear()
     .domain(extentSize) // extentSize = d3.extent(data, d => d.size)
     .range([1, gWidth / 20])

Axis

這裡簡單的直接透過 d3.axisTop()d3.axisLeft() 傳入 scale 方法,建立座標軸

Draw

這裡概述實作中的幾點

  1. 因為要預留 margin 範圍,我們透過 transform 來做位移
  2. <svg><defs> 區塊,定義了 clip-path 可被參照使用,超出 clip-path 範圍的就像遮罩一樣被裁切掉
  3. 使用 d3.brush() 建立可選取區塊,並註冊 brush 的 'end' 事件,觸發時進行座標軸與資料座標的更新轉換
  4. 對分布圖的區塊,建立 'dblclick' 的 event listener,觸發時,要還原至縮放前的大小與位置

建立畫布與坐標軸

function setupCanvas() {
  // 1. Clear last painted
  d3.select('.canvas').selectAll('svg').remove();
  // 2. Create svg
  const svg = d3.select('.canvas').append('svg')
    .attr('width', width)
    .attr('height', height)
    .append('g')
    .attr('transform', `translate(${margin.left}, ${margin.top})`);
  // 3. Define clip-path to clip graph out of the bounds
  svg.append('defs')
    .append('svg:clipPath')
    .attr('id', 'clip')
    .append('svg:rect')
    .attr('width', gWidth)
    .attr('height', gHeight)
    .attr('x', 0)
    .attr('y', 0);
  // 4. Append x axis
  svg.append('g')
    .attr('id', 'axis_x')
    .call(xAxis);
  // 5. Append y axis
  svg.append('g')
    .attr('id', 'axis_y')
    .attr('transform', `translate(0,0)`)
    .call(yAxis);
  return svg;
}
svg = setupCanvas();

建立分布圖區塊

function setupScatter() {
  // 6. Create scatter section to place circles
  const scatter = svg.append('g')
    .attr('id', 'scatterplot')
    .attr('clip-path', 'url(#clip)')
    .on('dblclick', onDblClicked);
  // 7. Add circles
  scatter.selectAll('.dot')
    .data(data)
    .join('circle')
    .attr('class', 'dot')
    .attr('r', d => size(d.size))
    .attr('cx', d => cx(d.x))
    .attr('cy', d => cy(d.y))
    .attr('opacity', 0.5)
    .style('fill', '#d989d6');
  return scatter;
}
scatter = setupScatter();

建立選取範圍

function setupBrush() {
  const brush = d3.brush()
    .extent([[0,0],[gWidth, gHeight]])
    .on('end', onBrushEnd);
  return brush;
}
brush = setupBrush();
scatter.append('g')
  .attr('class', 'brush')
  .call(brush);

Event Listeners

  • on brush end
function onBrushEnd(ev) {
  const s = ev.selection;
  if (!!s) {
    x.domain([s[0][0], s[1][0]].map(x.invert, x));
    y.domain([s[0][1], s[1][1]].map(y.invert, y));
    cx.domain([s[0][0], s[1][0]].map(cx.invert, cx));
    cy.domain([s[0][1], s[1][1]].map(cy.invert, cy));
    scatter.selectAll('.brush').call(ev.target.clear);
    zoom();
  }
}
  • zoom
function zoom() {
  scatter.transition().duration(750);
  svg.select('#axis_x').transition().call(xAxis);
  svg.select('#axis_y').transition().call(yAxis);
  scatter.selectAll('circle')
    .transition()
    .attr('cx', d => cx(d.x))
    .attr('cy', d => cy(d.y))
    .attr('r', d => size(d.size));
}
  • on dblclick
function onDblClicked() {
  x.domain(extentX);
  y.domain(extentY);
  cx.domain(extentX);
  cy.domain(extentY);
  svg.select('#axis_x').transition().call(xAxis);
  svg.select('#axis_y').transition().call(yAxis);
  // avoid circular definition
  d3.select('#scatterplot')
    .selectAll('circle')
    .transition()
    .attr('cx', d => cx(d.x))
    .attr('cy', d => cy(d.y))
    .attr('r', d => size(d.size));
}

做完以後發現,如果要在縮放的時候,讓表示資料範圍大小圓形,也能跟著放大,用 .zoom() 實現應該會更簡單直覺
下次就繼續來優化這個範例!

Reference


上一篇
[01] 從範例學 D3.js - Introduction
下一篇
[03] 從範例學 D3.js - Zoom
系列文
從範例學 D3.js3
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言